A Telegram Bot in Elixir feat. LiveView

I asked my network on Twitter about noting ideas quickly and got a lot of good responses. One mentioned saving them in Telegram. I don’t think I want to do specifically that but I do want a minimum friction way of noting ideas for later review and refinement. And sending them to a Telegram chat would be quite nice. So I started on the path of something like a note-taking system using Telegram for ingesting quick notes. And I want to share the satisfaction I felt with seeing the near real-time way that it works.

I’ll show some of the process I went through so you can repeat it for your own needs but the repo is open if you want to see where I’ve taken it since. You can find it as lawik/noted on GitHub. This guide recreates a simpler approach based off of what I learnt setting that up.

Setting up your project

I’m using Elixir and Phoenix with LiveView. You need to have the Phoenix project generator installed to follow along, you can follow the Phoenix installation instructions to get it.

shell /
mix phx.new --live noted

It should create your project, you can certainly name it something different than noted. I left Ecto in because you’ll likely want it at some point though I won’t use it in this post.

Some initial setup

In lib/noted/application.ex I comment out the Noted.Repo because we’re not using the DB.

In mix.exs for deps I add:

elixir mix.exs
    ..
      {:telegram, git: "https://github.com/visciang/telegram.git", tag: "0.7.0"},
      # Gun mismatch with telegram and cowboy
      {:cowlib, "~> 2.7", override: true}
    ..

The cowlib thing has to do with some weirdness in the telegram library deps. But adding this should allow you to do mix deps.get and have it work.

Get yourself a Telegram bot

So get situated with a Telegram account and add The Botfather (Telegram link) as someone to talk to and that’s Telegrams bot for creating bots. Very meta. Pretty convenient.

You write /newbot to him and he’ll guide you through the rest. You’ll receive a secret that you should squirrel away and we’ll use it as a an API key for running our bot.

You’re going to want a convenient client to test things as well. I’m using the MacOS desktop client on this machine, it is nice and native.

Minimum Viable Bot

It needs to be self-aware. So let’s make that happen. To protect the bot token I put it in a file in my home dir called .mybotcredentials or something similar. This file is just:

shell ~/.mybotcredentials
export TELEGRAM_BOT_SECRET="my_secret_goes_here"

And before running the server I do: source ~/.mybotcredentials

This prevents me from accidentally committing the damned things.

Okay, time to create this bot. Create a file in the project named lib/noted/bot.ex. In this file we do this:

elixir lib/noted/bot.ex
defmodule Noted.Bot do
  use GenServer
  require Logger

  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts, opts)
  end

  @impl GenServer
  def init(opts) do
    {key, _opts} = Keyword.pop!(opts, :bot_key)

    case Telegram.Api.request(key, "getMe") do
      {:ok, me} ->
        Logger.info("Bot successfully self-identified: #{me["username"]}")

        state = %{
          bot_key: key,
          me: me,
          last_seen: -2
        }

        {:ok, state}

      error ->
        Logger.error("Bot failed to self-identify: #{inspect(error)}")
        :error
    end
  end
end

This is a GenServer and we want to add it to run when our application starts. So below your Noted.Endpoint, inside the list of children in lib/noted/application.ex add this:

elixir lib/noted/application.ex
..
{Noted.Bot, bot_key: System.get_env("TELEGRAM_BOT_SECRET")}
..

Now you can make sure you’ve run mix deps.get and then run mix phx.server to try it out. Check your logs, the happy message should be there.

Set yourself up to receive updates

So it still doesn’t really do anything. We will be using long polling which is a neat way of getting updates from a Telegram bot without publishing the server on the web on some real domain and in some systems it might be a bit of a pain. In Elixir, on a GenServer it is comparatively trivial. At least if you know your GenServers.

Just after we set up our state and before we return {:ok, state} in the bot we need to add a call to next_loop(). So the your init/1 looks like this:

elixir lib/noted/bot.ex
..
  def init(opts) do
    {key, _opts} = Keyword.pop!(opts, :bot_key)

    case Telegram.Api.request(key, "getMe") do
      {:ok, me} ->
        Logger.info("Bot successfully self-identified: #{me["username"]}")

        state = %{
          bot_key: key,
          me: me,
          last_seen: -2
        }
        next_loop()

        {:ok, state}

      error ->
        Logger.error("Bot failed to self-identify: #{inspect(error)}")
        :error
    end
  end
..

Now to add the rest. This GenServer will send itself a message saying “ey, check for updates” and then act on that message until the activity is done and then call next_loop/0 again to trigger another message. We’ll start with acting on the message with a handle_info/2 callback. In your bot GenServer module:

elixir lib/noted/bot.ex
..
  @impl GenServer
  def handle_info(:check, %{bot_key: key, last_seen: last_seen} = state) do
    state =
      key
      |> Telegram.Api.request("getUpdates", offset: last_seen + 1, timeout: 30)
      |> case do
        # Empty, typically a timeout. State returned unchanged.
        {:ok, []} ->
          state

        # A response with content, exciting!
        {:ok, updates} ->
          # Process our updates and return the latest update ID
          last_seen = handle_updates(updates, last_seen)

          # Update the last_seen state so we only get new updates on the
          # next check
          %{state | last_seen: last_seen}
      end

    # Re-trigger the looping behavior
    next_loop()
    {:noreply, state}
  end

  defp handle_updates(updates, last_seen) do
    updates
    # Process our updates
    |> Enum.map(fn update ->
      Logger.info("Update received: #{inspect(update)}")
      # Offload the updates to whoever they may concern
      broadcast(update)

      # Return the update ID so we can boil it down to a new last_seen
      update["update_id"]
    end)
    # Get the highest seen id from the new updates or fall back to last_seen
    |> Enum.max(fn -> last_seen end)
  end

  defp broadcast(update) do
    # Send each update to a topic for others to listen to.
    Phoenix.PubSub.broadcast!(Noted.PubSub, "bot_update", {:update, update})
  end

  defp next_loop do
    Process.send_after(self(), :check, 0)
  end
..

Try it out, it should log messages as they are sent to your bot.

Getting real-time with LiveView

We can repurpose the existing LiveView that the Phoenix generator gives us. So just open lib/noted_web/live/page_live.ex and its sibling the template lib/noted_web/live/page_live.html.leex. In the page_live.ex file we change it to this:

elixir lib/noted_web/live/page_live.ex
defmodule NotedWeb.PageLive do
  use NotedWeb, :live_view

  @impl true
  def mount(_params, _session, socket) do
    Phoenix.PubSub.subscribe(Noted.PubSub, "bot_update")
    {:ok, assign(socket, messages: [])}
  end

  @impl true
  def handle_info({:update, update}, socket) do
    {:noreply, assign(socket, messages: [to_message(update) | socket.assigns.messages])}
  end

  defp to_message(%{"message" => message} = _update) do
    firstname = get_in(message, ["from", "first_name"])
    lastname = get_in(message, ["from", "last_name"])
    username = get_in(message, ["from", "username"])

    from =
      case {firstname, lastname, username} do
        {nil, _, username} -> username
        {firstname, nil, _} -> firstname
        {firstname, lastname, _} -> "#{firstname} #{lastname}"
      end

    text = get_in(message, ["text"])
    %{from: from, text: text}
  end
end

And we change the page_live.html.leex template file to:

html lib/noted_web/live/page_live.html.leex
<section class="phx-hero">
  <h1><%= gettext "Welcome to my Bot!" %></h1>

  <%= for message <- Enum.reverse(@messages) do %>
  <p><strong><%= message.from %>: </strong><%= message.text %></p>
  <% end %>
</section>

Run this and you should see any message to type in the Telegram bot show up. I find it very gratifying just how instantaneous it can be.

In closing

This is just a start and in the noted repo you will find some of what I’ve done to push it further, structure the bot a little bit more. Add some responses. You can just try it out by cloning and setting it up.

I implemented some authentication. Currently this means that notes are separated by user. It also means that Noted allows new users by default if they find your bot :D

I implemented the database backing to save notes and tags. I added a bunch more LiveView code. I separated the polling for getUpdates from the handling of the updates a bit more and added a Supervisor to the pairing of GenServers.

Currently on my todo-list is to receive files and media in a nice way and integrate those with the markdown support. If you want practice with Tailwind CSS I’d love for someone to make it look better.

But this particular project aside I find the realtime nature of chat bots very satisfying. Telegram has a well-working API, nice native clients and a hilariously large feature set. And with how fast you can get this up and running when it isn’t your first time I really recommend trying it for things. Heck, you could use it for sending you notices about your app catching fire or occasional status updates from your system. There are so many fun options.

Slightly timely update, especially if you are curious about LiveView or want more of me. Me and a wild gang of Elixir community folks just put our new podcast out there. It is called BEAM Radio and you find it at beamrad.io.

I hope you’ve enjoyed the post. Let me know if this is something you would like to see more of as I have quite a few things I could go deeper into from this project. You can let me know via email at lars@underjord.io or on Twitter where I’m @lawik.