Approach for command handlers in Telegram

I’d want to give Elixir a try, creating a Telegram bot. At this point, I’m lost at how I should organize my command handlers. I’d want to use pattern matching, e.g.

defmodule MyBot.CommandHandler do
  ..
  def handle_command(:some_command, args), do: ...
  def handle_command(:other_command, args), do: ...
end

But I want to avoid bloating up this file as I continue to create new commands. What would be a good approach to that problem?

You could try to use protocols for your commands, where each command is represented as a struct and args are struct fields.

defprotocol MyBot.Command do
  def exec(command)
end

defmodule MyBot.Commands.SomeCommand do
  defstruct [...]
  
  defimpl MyBot.Command do
    def exec(command), do: ...
  end
end

defmodule MyBot.Commands.OtherCommand do
  defstruct [...]

  defimpl MyBot.Command do
    def exec(command), do: ...
  end
end
1 Like

I interact with Telegram Bot API via Webhooks and use a Cowboy Plug server.
Here’s an excerpt from my Plug router file:

  post "/api/updates/:telegram_token" do
    Logger.debug("Received Update: #{inspect(conn.params, pretty: true)}")
    resp = Handlers.handle(conn.params)

    conn
    |> put_resp_content_type("application/json")
    |> send_resp(200, Jason.encode!(resp))
  end

  match _ do
    send_resp(conn, 404, "Not Found")
  end

Then inside Handlers module I’m trying to logically dispatch Updates:

  def handle(%{"callback_query" => _} = update) do
    CallbackHandler.handle(update)
  end

  def handle(%{"pre_checkout_query" => _} = update) do
    CheckoutHandler.handle(update)
  end

  def handle(%{"message" => %{"successful_payment" => _}} = update) do
    CheckoutHandler.handle(update)
  end

  def handle(%{"message" => %{"contact" => _}} = update) do
    ContactHandler.handle(update)
  end

  def handle(%{"message" => %{"reply_to_message" => _}} = update) do
    ReplyHandler.handle(update)
  end

  def handle(%{"message" => _} = update) do
    MessageHandler.handle(update)
  end

  def handle(%{"edited_message" => _} = update) do
    EditedMessageHandler.handle(update)
  end

  def handle(update) do
    Logger.debug("Unhandled Update: #{inspect(update, pretty: true)}")
    %{}
  end

Then inside MessageHandler I’m pattern matching on the type of the command and delegating the work to the corresponding business logic module:

  @username :my_awesome_bot

  def handle(%{"message" => %{"text" => text}} = update) when text in ["/help", "/help@#{@username}"] do
    Command.help(update)
  end

  def handle(%{"message" => %{"text" => text}} = update) when text in ["/ping", "/ping@#{@username}"] do
    Command.ping(update)
  end

That’s the best code organisation pattern I could come up with. Please let me know if you have additional questions.

1 Like

Yeah, I’ve thought of something like

defmodule MyBot.MessageHandler do
  def handle(%{"message" => %{"text" => text}} = update) do
    # some function to convert `/help` into `:help`, but skip undefined commands so I don't overflow the VM with atoms
    {:ok, command_atom} = extract_atom(text)
    CommandHandler.handle(command_atom, ...)
  end
end

defmodule MyBot.CommandHandler do
  def handle(:help, ...), do: MyBot.Commands.Help.handle(...)
  def handle(:something_else, ...), do: MyBot.Commands.SomethingElse.handle(...)
end

Or maybe I could construct an atom referencing a module (e.g., /help -> MyBot.Commands.Help) and call handle on it directly, thus removing CommandHandler. Or maybe make the handlers implement a protocol and pass them to a module that works with the implementations. I assume I could do that because module names are atoms.

What do you think of that?

If I had lots of similar commands I would’ve probably generated handlers at compile time using metaprogramming:

def MessageHandler do
  @username :my_awesome_bot
  @commands [:ping, :pong, :foo, :bar]

  for command <- @commands do
    private_chat_command = "/#{command}"
    group_chat_command = "#{private_chat_command}@#{@username}"

    def handle(%{"message" => %{"text" => text}} = update)
        when text in [unquote(private_chat_command), unquote(group_chat_command)] do
      apply(CommandModule, unquote(command), [update])
    end
  end
end

However, I think it’s not worth it unless you’re going to handle significant amount of commands.

2 Likes

I’m new to Elixir, so I’m a bit scared of metaprogramming, but I think I’ll go with that once I feel more confident with the language. Thanks a lot! For now, I’m going to def a bunch of pattern-matched handlers and call the corresponding module inside them.