Каркас для Telegram-бота на Erlang

от автора

Некоторое время назад я активно взялся за изучение языка Erlang. В рамках обучения на практике я решил написать бота для Telegram. Фантазии выдумать оригинальную идею бота не хватило, поэтому всё, что получилось на выходе — это хорошая, честная заготовка, в которую можно добавить свои команды, свои обработчики и с этим можно будет жить. Этакий шаблон, который можно заточить под себя при минимальных временных затратах. Подробно — под катом.

Начало

Будем играть в «больших дяденек» — берем библиотеки от Nine Nines – cowboy в качестве веб-сервера, lager для логгинга. Логгинг здесь как таковой, не особо и нужен — но надо было научиться использовать lager, поэтому он тут. Была мысль использовать и gun, но, поразмыслив, я все-таки отказался от него в пользу httpc.

Собственно, Erlybot представляет собой классическое OTP-application – супервизор и два gen_server-а в качестве воркеров. Почему именно два — объясню ниже.

Все крутилось на простеньком VPS без доменного имени. SSL-сертификат самоподписанный. Cowboy спрятан за nginx-ом, nginx слушает 443 порт, и проксирует запросы на localhost:7770, при помощи несложного location:

location

server {         listen 443 ssl;         server_name <IP_ADDRESS>;          ssl_protocols       TLSv1 TLSv1.1 TLSv1.2;         ssl_certificate /etc/nginx/ssl/mypub.pem;         ssl_certificate_key /etc/nginx/ssl/mypriv.key;          location /<TOKEN> {             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://<IP_ADDRESS:PORT>/<TOKEN>" https://api.telegram.org/bot<TOKEN>/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.

На этом всё, спасибо за внимание. Буду рад адекватной критике.
ссылка на оригинал статьи https://habrahabr.ru/post/327696/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *