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

На этом всё, спасибо за внимание. Буду рад адекватной критике.
Поделиться с друзьями
-->

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


  1. square
    02.05.2017 04:46
    +2

    Вам стоит чуть-чуть подождать с публикацией своих работ и ещё некоторое время продолжить обучение, больше подглядывая, как сделано у мастеров. Обратите внимание на такую деталь, сейчас при малейшей ошибке во время обработки сообщения у вас упадет вообще всё, начиная от вебсервера и заканчивая хранилищем, вы уверены, что хваленая надежность Ирла выглядит именно так?


    1. JC_IIB
      02.05.2017 05:50

      Можете привести конкретный кейс, когда случится «падение всего»?


      1. square
        02.05.2017 07:02
        +1

        У вас циклическая зависимость сервера от обработчика его команд, _любая_ ошибка времени выполнения (тестов у вас, конечно, нету) в обработчике (процессоре) приведет к его перезапуску супервизором, что закономерно вызовет перезапуск вебсервера и пересоздание хранилища.

        При этом, обратите внимание, что парсер у вас сидит в другой ветке дерева супервизора и делает _синхронный_ вызов процессора, соответственно, не получив ответа, он тоже выбросит exit и тоже покончит с собой, что приведет ещё и к его перезапуску.

        Если ошибочный запрос будет приходить хотя бы дважды в секунду, то через десять секунд умрет главный супервизор (такие у вас настройки), смерть которого приведет к смерти всей app, которая у вас запускается без ключа -heart (или systemd) и перезапустить её будет некому.

        В этот момент отдаст богу душу весь сервис целиком и история закончится. Удачи.


        1. JC_IIB
          02.05.2017 07:34

          "любая ошибка времени выполнения"

          Например?
          Вопрос не праздный, хотелось бы знать, какими тестами (если уж речь пошла о них) такое ловится.


          По поводу синхронного вызова процессора и systemd согласен, но это легко фиксится.


          1. square
            02.05.2017 08:50
            +1

            читайте erlang in anger и lyse. для тестов подойдет quickcheck или proper.


            1. JC_IIB
              02.05.2017 09:12

              quickcheck… proper.

              Спасибо. А как же eunit с CT?


              1. square
                02.05.2017 16:29
                +1

                Ну сколько кейсов вы в состоянии написать ручками? Десяток? Два? А генератор даст вам натуральное супероружие. Пошукайте ютуб, там есть прекрасные выступления John Hughes.


                1. JC_IIB
                  02.05.2017 16:31

                  Да, я уже начал читать про названное вами. Похоже, крайне удобная штука это property-based тестирование.


    1. aleki
      02.05.2017 16:09

      Подскажите, пожалуйста, архитектуру для сложного Telegram бота с большим кол-вом динамических inline клавиатур.


      1. square
        02.05.2017 16:24

        Я не очень хорошо понимаю, что там и как работает, т.к. не пользуюсь ни самим Т, ни его апишкой. Но полагаю, что использование ranch и gen_statem именно то, что вам нужно.


  1. AstarothAst
    02.05.2017 09:19

    Нет тестов, да. Совсем. Это плохо, но их нет

    Эрланг тот редкий случай, когда рабочие тесты действительно хоть что-то гарантируют, не писать их — лишать самого себя уверенности в завтрашнем дне ;)