[Из песочницы] Каркас для Telegram-бота на Erlang

habralogo.jpg
Некоторое время назад я активно взялся за изучение языка 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 ;

        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.

На этом всё, спасибо за внимание. Буду рад адекватной критике.

Комментарии (0)

© Habrahabr.ru